Skip to content

Eligibility gate — Discord membership and minimum account age before a key is issued (lore 0189) - #230

Merged
adamkoot merged 13 commits into
developfrom
feat/0189_eligibility-gate-discord-membership-and-account-age
Aug 21, 2026
Merged

Eligibility gate — Discord membership and minimum account age before a key is issued (lore 0189)#230
adamkoot merged 13 commits into
developfrom
feat/0189_eligibility-gate-discord-membership-and-account-age

Conversation

@adamkoot

@adamkoot adamkoot commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Eligibility gate — Discord membership and minimum account age (lore task 0189)

The whole of the epic's abuse story. Until this lands, lore task 0187 issues a key
to anyone holding a Discord account — acceptable on a dev distribution, and not
something that may reach production.

Stacked on #227 (lore task 0188). Based on feat/0188_… on purpose: this slice
edits the same portal files. Merge #227 first, or retarget this at develop after
it lands.

What it does

A key is issuable only by a member of the configured Discord guild whose account is
older than a configured minimum. Both facts are proved per action, by a fresh
OAuth round-trip, and neither is ever carried in the session (ADR 0010 §8) — a signed
"eligible" claim would date the verdict to sign-in time.

Path Re-auth Checks
Sign in identity only
Issue a key yes membership (pending === false) + account age
Reveal / usage no session only
Rework (lore task 0191) yes membership only — age is never re-checked
Revoke (lore task 0192) no session only — a deliberate exception

The last two rows are documented in eligibility.rs before those slices exist, so
they inherit the table rather than re-deciding it.

The structural change

/key is now fully read-only — no create, no attach, no delete. GET and POST
both reach the same reveal. The create-capable reconciler survives as issue_for,
reachable only from the action=issue callback.

That makes "issue is unreachable with a session cookie alone" a property of the call
graph rather than a guard someone can remove: a session cookie can cause zero
control-plane writes. It also retires 0187's SameSite=Lax GET-may-create argument.

Costs accepted and documented at the module: a hand-deleted key answers no_key
instead of resurrecting, an unattached orphan reveals un-repaired, duplicates wait
for the next issue to converge. Each heals on one gated press.

Three outcomes, not two

Only Discord's own 10007/10004 on a 404 reads as "not a member". A 401, 403,
429, 5xx, an unrecognised 404 body, or an absent pending field is unknown:
refuse, but never claim non-membership. "Could not verify" is fixable by waiting;
"you are not a member" is an accusation the visitor can only disprove by joining a
server they may already be in.

?issue=failed is kept separate from ?issue=unknown for the same reason — one says
Discord could not vouch for you, the other says you are fine and our key service was
not.

Two operator-seeded SSM parameters

/prices/{env}/discord-guild-id and /prices/{env}/min-account-age-minutes, resolved
per issuance through the Parameters and Secrets extension, so an operator's
put-parameter takes effect without a redeploy (the extension's ~5 min cache is the
only delay). Probed once at cold start, so a bad seed is an Init Errors event with
the parameter named rather than a per-visitor refusal.

Nothing creates them. A CloudFormation-managed parameter is CDK-owned, so the next
cdk deploy would silently restore the committed value — un-flipping production back
to the test guild after lore task 0179 step 4. verify-openapi-routes.mjs check 7
refuses any synthesized template that would create either, and both halves of the
check are non-vacuity-tested.

Review round

A code review returned seven findings. Each was verified against the code before
acting; none was a false positive. Five were fixed directly, two after confirming no
future task owned them.

  • The timeout budget exceeded the Lambda timeout. RECONCILE_DEADLINE was sized
    for 0187, where the reconciliation was the request. With four network calls in
    front of it the worst case reached ~29s against a 15s function — an API Gateway
    502 instead of ?issue=failed, and possibly a key created but never attached.
    The deadline is now derived from what is left of the invocation, measured from
    request entry, with a floor below which no reconciliation starts.
  • prompt=none was missing. Three comments asserted that Discord does not
    re-prompt for consent on repeat authorisation; the parameter that makes that true
    was never sent. Every issue, every retry after a refusal and every future rework
    was a full consent screen — the cost the per-action model's own justification
    denied.
  • ?issue=cancelled and ?issue=denied. Not a sixth and seventh verdict: the
    five states are outcomes of a completed check, these happen before one starts,
    and sign-in has had exactly this pair since 0186. Issue had neither, so a cancelled
    press landed on ?signin=cancelled — whose banner renders only in the signed-out
    branch an issue round-trip has by definition left.
  • One is_snowflake, shared. The seed was validated in one place and consumed in
    another, and only the consumer checked the shape, so a guild name passed the
    cold-start probe and then refused every visitor as unknown, indefinitely. The
    task's own parameter table named such a value and is corrected with it.
  • GET /key fired twice per load (measured), because load depended on an
    inline callback prop. And ?issue=ok could render beside "you have no API key yet"
    in GetApiKeys's eventual-consistency window — offering a second key to somebody
    who had just been given their first.

Deliberately not fixed here: the wildcard apigateway:DELETE on /apikeys/*. It is
a real weakness — "own" is enforced only in code — but lore task 0194's checklist
already names it verbatim, mitigation included, and fixing it here would remove the
audit's subject.

Still open, and owned by the operator

Every acceptance criterion this slice's code can meet is met and asserted by a test.
Two items that were previously carried in that list are not criteria for code at all
and have been dropped from it: Step 0's five measurements (lore task 0180 items 1–5)
and the consent-screen captures need a second guild with screening off and a second
non-member account — manual prerequisites owned by the operator. They stay recorded
in Step 0's status note, in the task's Future Work, and in the two runbooks that
carry the procedure. The archived result tables were checked and are empty
placeholders, so nothing was carried in and nothing was invented.

The code is written to the documented safe rules instead: only a confirmed
10007/10004 reads as non-membership, everything else refuses without accusation,
and an absent pending never passes. Either measured outcome changes at most one
match arm each (Design Decisions 4-6 in the task).

Verified

cargo fmt --all --check · cargo clippy --workspace --all-targets (0 warnings in
prices-api) · cargo test --workspace (553 passed, 0 failed) ·
cargo check -p prices-api --features lambda (every local seam compiled out) ·
nx run-many -t lint typecheck build test (65 frontend tests) · nx format:check --all ·
make -C infra synth-production · openapi:lint · openapi:verify-routes (check 7 live) ·
openapi:verify-servers

🤖 Generated with Claude Code

… key is issued

The issue path moves behind a fresh OAuth round-trip (action=issue in the
signed state): the callback checks Stellar Discord membership
(GET /users/@me/guilds/{guild}/member with the just-exchanged user token)
and the snowflake-derived account age against two operator-seeded SSM
parameters, and only then runs 0187's reconciler. Three outcomes, not two:
only Discord's own 10007/10004 on a 404 is 'not a member'; 401/403/429/5xx,
unrecognised shapes and an absent pending field refuse without accusation.
The /key route goes fully read-only — a session cookie alone can cause zero
control-plane writes — and answers no_key when there is nothing to reveal.
Scope becomes exactly 'identify guilds.members.read', compared as a set.
'Get my API key' becomes a top-level link into the issue round-trip; the
key section fetches the (now read-only) reveal on mount and renders the
five ?issue= landing states in the wording this task decides: not-a-member
names the server and links discord.gg/stellardev, too-young renders the
backend's wait_secs as a wait rather than a rejection, and could-not-verify
is explicitly not an accusation. Landing params are one-shot — read once,
stripped from the URL — which also closes 0186's O10 stale-banner item.
The landing page states both prerequisites before the visitor authenticates.
…docs

compute-stack passes PORTAL_GUILD_ID_PARAM and PORTAL_MIN_ACCOUNT_AGE_PARAM
(names only, unconditional) and states the read grant explicitly beside the
plan-id one. verify-openapi-routes gains check 7: the handler carries both
names exactly, and no synthesized template may CREATE either parameter — a
CDK-owned parameter would be restored by the next deploy, un-flipping
production back to the test guild after 0179. The runbook gains §2a (the
put-parameter seeding, the ownership split, the no-redeploy property) and
its scope step is rewritten to the two-scope done state; the README gains
the local procedure for the gate and its refusal states.
…asurements

Step 0's tables stay empty with a dated deferral — the archived 0180 notes
were found unmeasured and the prerequisites are operator-owned; the code is
written to the documented safe rules with each unmeasured behaviour behind
one reversible arm. Ticks the code-side acceptance criteria and names the
two operator steps that remain.
All seven were verified against the code before acting; none was a
false positive.

The reconcile deadline is now derived from what is left of the
invocation rather than reused from 0187, where the reconciliation was
the whole request. With four network calls in front of it the worst
case reached ~29s against a 15s Lambda, which answers an API Gateway
502 instead of ?issue=failed and can leave a key created but never
attached.

The authorize URL sends prompt=none. Three comments asserted that
Discord does not re-prompt for consent on repeat authorisation and the
parameter that makes that true was never sent, so every issue, every
retry after a refusal and every future rework was a full consent
screen — the cost the per-action model's own justification denied.

?issue=cancelled and ?issue=denied give the issue flow the pre-check
pair sign-in has had since 0186. Without them a cancelled press landed
on ?signin=cancelled, whose banner renders only in the signed-out
branch an issue round-trip has by definition left: the visitor came
back to an unchanged dashboard with nothing said.

guild_id() and member_url now share one is_snowflake. Only the consumer
checked the shape, so a guild name passed the cold-start probe and then
refused every visitor as unknown, indefinitely. The task's parameter
table named such a value, and is corrected with it.

The key fetch runs once per load again — load() depended on an inline
callback prop, so reporting the key re-fired the mount effect — and
?issue=ok no longer renders beside "you have no API key yet", a window
that offered a second key to somebody who had just been given a first.

Left alone deliberately: the wildcard apigateway:DELETE on /apikeys/*,
which 0194's checklist already names verbatim, mitigation included.
Corrects two claims the round made stale — the redirect count and the
test totals — and adds decisions 18 to 22 plus the two entries under
Issues Encountered.

The entry worth keeping is why a comment can be load-bearing and still
describe code that was never written: three places asserted that
Discord does not re-prompt for consent, and nothing sent prompt=none.
Neither was a criterion this slice's code could meet: both are manual
prerequisites owned by the operator, and both remain recorded in Step
0's status note, in Future Work, and in the two runbooks they name.

What is left is eleven criteria, all met, all asserted by tests.

@karczuRF karczuRF left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review of the eligibility gate. The security-relevant core is sound — I checked and cleared the scope set-comparison and member_url snowflake validation (path injection closed), classify_member_response's three-outcome table, the snowflake epoch math and wait_secs ceiling, the ISSUE_BUDGET/RECONCILE_FLOOR derivation against the real 15s apiHandler.timeoutSeconds, the read-only lookup vs. create-capable issue_for split (the reconciler is semantically unchanged; only KeyValue is dropped from Outcome), UsageCache being Arc-backed so the cloned handle in IssueDeps evicts the same cache, the Action::Issue state-token round-trip and mismatch refusal, the lambda/aws-mtls feature gating, and useOneShotParams.

Five findings below; the first is the significant one.


Two documented risks I did not file as inline bugs, but that are worth the team's eyes:

  1. pending: None -> Unknown means that if Discord's REST member object omits pending, every member is refused as "could not verify", indefinitely. Lore task 0180 item 2 is still unmeasured, so this is a fail-closed-for-100%-of-users path resting on unverified Discord semantics.
  2. prompt=none is now on the shared authorize_url, so it also changes first-time sign-in behaviour, not just issuance.

Reviewed as the top of a two-PR stack on top of #227. Diff is correctly scoped by the branch target; if #227 takes review fixes, this needs a rebase before merge.

@@ -382,6 +451,14 @@ async fn callback(
Err(error) => return refuse_discord("token exchange", error, drop_pending),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discord failures on an action=issue callback bypass the designed ?issue= redirect.

They fall through to refuse_discord, producing a bare 502 JSON page worded "could not complete sign-in" with no link back — instead of the ?issue=unknown / ?issue=failed redirect this PR's whole three-outcome design is built around. The visitor pressed "issue a key", not "sign in", and lands on a dead end.

Same hole at issue.rs:262 (the identity read), and in login's unwired 503.

Most likely trigger on this PR specifically: UnexpectedScope, if the Developer Portal registration still carries only identify while the new prompt=none suppresses the re-consent that would otherwise have granted guilds.members.read. That combination turns a config lag into an unexplained 502 rather than the "could not verify" path you designed for exactly this.

Comment thread web/portal/src/app/app.tsx Outdated
*/
function describeWait(waitSecs: string | null): string {
const parsed =
waitSecs && /^\d{1,7}$/.test(waitSecs) ? Number(waitSecs) : NaN;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

describeWait rejects rather than clamps large waits, understating them drastically.

The /^\d{1,7}$/ guard rejects any wait_secs over 7 digits. So a min-account-age-minutes above roughly 16 weeks renders a months-long wait as "about a few minutes" — the most misleading possible failure direction for a value an operator sets via put-parameter without a redeploy.

Clamping to the top bucket would fail in the honest direction.

key yet" is the page contradicting itself about the one fact the
visitor came for. The settling branch below says what is true. */}
{issue === 'ok' && view.state !== 'none' && (
<p data-testid="issue-ok">Your key is ready.</p>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?issue=ok can render above a failed reveal.

The banner is guarded only against view.state === 'none', so an error state still shows "Your key is ready." directly above "Could not get your API key" — the same self-contradiction the none guard was written to avoid.

The guard wants to be against the states where the key is not on screen, not just none.

Comment thread web/portal/src/app/app.tsx Outdated
{view.state === 'none' && issue === 'ok' && (
<p data-testid="issue-ok-settling">
Your key was created, and is taking a moment to appear.{' '}
<button type="button" onClick={load}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settling "Check again" button gives no feedback on a retry that still finds nothing.

It doesn't reset view to loading, so pressing it when the key still hasn't settled produces zero visible change — the user can't tell the press registered, and the natural read is that the button is broken.

Usage's Refresh (line 774) does the opposite and is the right model here.

for (const file of templateFiles) {
const tpl = readJson(join(cdkOut, file), 'synthesized template');
for (const [id, resource] of resourcesOfType(tpl, 'AWS::SSM::Parameter')) {
const name = resource.Properties?.Name;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check 7(b) is evadable by a non-literal parameter name.

It only inspects Properties.Name when that value is a literal string. A CDK-created SSM parameter whose name synthesizes to an Fn::Join / Fn::Sub — which is what you get the moment anyone interpolates the env into it, i.e. the natural way someone would write /prices/${env}/discord-guild-id — slips straight past the "never CDK-owned" guard.

That matters more than a usual CI gap, because the guard exists precisely to stop a cdk deploy from silently restoring a committed value and un-flipping production back to the test guild after lore task 0179 step 4. Treating any non-string Name as a failure (or resolving the intrinsic) would close it.

…er it

The five findings from PR #230, plus the two risks it filed as prose and
five more found re-reading the diff.

The significant one: a Discord failure on an `action=issue` callback fell
through to the sign-in arm's `502 "could not complete sign-in"` — a bare
JSON page, about an action the visitor did not take, with no link back.
`UnexpectedScope` lands on `?issue=denied`, the same landing Discord's own
`invalid_scope` already reaches, because it is the same registration drift
seen one step later; everything else lands on `?issue=unknown`. The unwired
`/auth/login?action=issue` joins them at `?issue=failed` instead of a `503`
envelope, and now logs the deployment fault it previously reported to
nobody.

`prompt=none` moves off the shared authorize URL onto the issue round-trip
alone. Sign-in is where the first authorisation always happens, so it is
the one path that cannot rest on an undocumented Discord behaviour — and
it is also where an account authorised under 0186's `identify` must be
re-shown the consent screen to grant `guilds.members.read` at all.

The rest: `describeWait` clamps a long wait into a bigger unit instead of
rejecting it into "a few minutes"; `?issue=ok` no longer sits above a
failed reveal, nor above a usage section offering to issue a second key;
"Check again" says it is checking; `?issue=failed`'s copy stops claiming a
check ran, since it is now reachable before one; and CI check 7(b)
resolves `Fn::Join`/`Fn::Sub` parameter names rather than skipping them,
refusing any name whose last segment it cannot read.

The issue path's usage-cache eviction was asserted nowhere — deleting it
left the workspace green, resurrecting 0188's R2 on the one page load that
follows an issue. It has a test now, proven non-vacuous.

Workspace 553 -> 558, app.spec.tsx 56 -> 61.
PR #230's five findings and two risks, the five problems the pass over the
whole diff turned up, and decisions 23 to 26 for the choices taken without
asking: the `prompt=none` narrowing, the redirect-only issue path, the
re-worded `?issue=failed`, and `?issue=ok` reporting the key's existence
by itself.

Records one finding as raised-not-taken (the same `502` dead end on the
sign-in arm, which is 0186's code and 0186's documented decision), and the
rebase onto 0188's tip as the remaining pre-merge step.
…at/0189_eligibility-gate-discord-membership-and-account-age

Brings 0188's four review rounds under 0189, which was cut before them. The
reviewer's closing note on #230 asked for this before merge.

Fourteen files conflicted. Ten were the same mechanical hunk — 0188 added
`AppConfig::portal_rate_limit`, 0189 added `portal_eligibility`, and every
test that builds the struct literally saw both. Both fields kept.

Three needed a decision:

- `compute-stack.ts` — both slices append to the api-handler's environment;
  both blocks kept, so the template now carries PORTAL_GUILD_ID_PARAM,
  PORTAL_MIN_ACCOUNT_AGE_PARAM and PORTAL_RATE_LIMIT together. Synth
  confirms all three.
- `app.spec.tsx` — 0189 refactored two failure tests onto the
  `signedInWithoutKey` helper that 0188 still wrote out inline. Helper form
  kept; 0188's name for one of them ("leaves the button pressable")
  described a button 0189 replaced with a link. `signedInWithoutKey` also
  moves onto 0188's `openConfig`, which its sibling had already taken in the
  automatic merge — split, the no-key dashboard would have rendered without
  the rate-limit line the backend now serves from /config.
- Two of 0188's O3 tests drove that same button and so failed on the merged
  tree, which git could not see because 0189 never touched their text. The
  property they protect is unchanged — a key reaching the screen while the
  mount-time usage load is still in flight must still trigger the refetch,
  and must trigger it once — so they were rewritten to 0189's arrival path
  (the read-only reveal resolving on mount) rather than deleted. Both still
  fail if the effect is broken.

The effect itself merged cleanly and correctly: 0188's O3 version, watching
`view.state`, supersedes 0189's C4 ref-read exactly as its own review round
decided, and 0189's `?issue=ok` hook feeds `keyOnScreen` alongside it. Its
comment is updated to name both arrivals instead of a press.

Workspace 570 passed, portal 76 passed, clippy clean, synth + openapi checks
green.
@adamkoot
adamkoot changed the base branch from feat/0188_usage-against-quota-on-the-dashboard to develop August 21, 2026 09:23
…ship-and-account-age

#227 put 0188 on develop, so this branch retargets there — and develop had
moved eleven commits further (0190, 0204, 0213, 0120, 0135) while this slice
was in review.

One conflict, in 0189's own task file: develop carries the stub as it stood
at `chore(lore-0189): activate task`, this branch carries the implementation
record written on top of it. Ours wholesale — everything develop's copy has
is a subset, apart from the older parameter-table wording that decision #21
deliberately corrected (`stellar_test` as a guild id was the trap that
refused every visitor).

Workspace 610 passed, portal 76 passed, clippy clean, fmt/format, synth and
the openapi checks all green.
rustc 1.98.0, released 2026-08-18, began passing
`-Wl,--fix-cortex-a53-843419` on aarch64-unknown-linux-gnu. Zig's linker
rejects the argument outright, so every aarch64 link fails and the
`Build Lambda bootstraps` step dies on the first crate that links —
`crc-fast`, pulled in by aws-smithy-checksums.

Nothing in this repo caused it: PR #227 passed on 1.97.1 and PR #230
failed on 1.98.0 with an identical Cargo.lock and the same
cargo-lambda 1.9.1 / zig 0.16.0 pair. `develop` did not show it because
its rust job has not run since 22 July.

cargo-zigbuild filters the argument since v0.23.0 (rust-cross/
cargo-zigbuild #451, fixed by #452), but cargo-lambda 1.9.1 still
vendors 0.20.1 — so upgrading cargo-lambda cannot reach the fix today.
Both pins come off together once a cargo-lambda release carries
zigbuild >= 0.23.

cargo-lambda is pinned as well because `pip3 install cargo-lambda`
resolves at run time, which is how the vendored zigbuild version — the
half that actually carries the fix — could change under a green build
without a commit.

Considered and rejected: `cargo lambda build --compiler cargo` on the
native ARM runner. ubuntu-24.04 ships glibc 2.39 against
provided.al2023's 2.34, so the failure would move from CI to the
deployed function.
@adamkoot
adamkoot merged commit 99bca3a into develop Aug 21, 2026
3 checks passed
@adamkoot
adamkoot deleted the feat/0189_eligibility-gate-discord-membership-and-account-age branch August 21, 2026 11:10
adamkoot added a commit that referenced this pull request Aug 21, 2026
Both shipped to `develop` today: 0188 in PR #227 (`a76d8a9`), 0189 in
PR #230 (`99bca3a`, approved by Oskar Karcz). Workspace ends at 558 Rust
tests and 61 portal tests, 0 failures.

0188 closes seven of eight criteria; "N requests move the number" cannot
be closed from a keyboard and waits on the deploy with 0187's live curl.
Carried forward from its four review passes: the cached "no key" that
survived the issue falsifying it, the eviction race that followed, and
the running-balance reset detection that keeps `used` and `remaining` in
one AWS period — the only evidence this system can produce about the
instant ADR 0010 correction #2 is open on.

0189 closes all twelve criteria in code, and deliberately does not close
Step 0: the five Discord measurements are operator-owned, the tables keep
a dated deferral, and no result was invented. The two findings worth
carrying are written into the task — `prompt=none` asserted by three
comments and sent by no code, and one `is_snowflake` now guarding both
the cold-start probe and the member URL.

Still operator-owned before production: the Developer Portal scope, the
two SSM seeds, and 0179 step 4. Nothing spawned; every follow-up already
has an owner.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants